iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Modern Web

Angular 進階實務 30天系列 第 23

Day 23:Angular Reactive Forms – FormArray 與動態集合

  • 分享至 

  • xImage
  •  

前言:從群組驗證到多筆資料

在上一篇,我們學會了如何處理 群組級驗證與跨欄位邏輯,解決了「欄位之間有依存關係」的問題。

而在實務專案裡,還有一個常見的需求是:

👉 使用者需要輸入多筆相同結構的資料,但是數量不一定。

舉例:

  • 購物車有多個商品
  • 報名表有多位參加者
  • 戲院座位表是一整個二維結構

這些場景,光靠 FormGroup 不夠,需要用到 FormArray 來表示「一組陣列型的表單集合」。


1. 為什麼需要 FormArray?

FormGroup

  • 適合固定結構(姓名、生日、聯絡方式)。

FormArray

  • 適合「一對多」或「多筆輸入」的情境:
    • 多個商品
    • 多個地址
    • 多個參加者
  • 可以動態 新增/刪除/修改 子項目。

可以把 FormArray 想成「裝著一群 FormGroup(或 FormControl)的陣列」。


2. 基礎案例:購物車商品清單

需求

  • 一張訂單可能包含多個商品。
  • 每筆商品需要:商品名稱、數量、價格。
  • 使用者可以動態新增/刪除商品。
  • 驗證:數量與價格都必須大於 0。

程式碼

import { FormBuilder, FormArray, Validators } from '@angular/forms';

form = this.fb.group({
  orderItems: this.fb.array([
    this.fb.group({
      productName: ['iPhone 15', Validators.required],
      quantity: [2, [Validators.required, Validators.min(1)]],
      price: [35000, [Validators.required, Validators.min(1)]]
    }),
    this.fb.group({
      productName: ['AirPods Pro 2', Validators.required],
      quantity: [1, [Validators.required, Validators.min(1)]],
      price: [7500, [Validators.required, Validators.min(1)]]
    })
  ])
});

// 幫助方法
get orderItems(): FormArray {
  return this.form.get('orderItems') as FormArray;
}

addItem() {
  this.orderItems.push(this.fb.group({
    productName: [''],
    quantity: [1],
    price: [0]
  }));
}

removeItem(i: number) {
  this.orderItems.removeAt(i);
}

UI 綁定思路

<div formArrayName="orderItems">
  <div *ngFor="let item of orderItems.controls; let i = index" [formGroupName]="i">
    <input formControlName="productName" placeholder="商品名稱">
    <input formControlName="quantity" type="number">
    <input formControlName="price" type="number">
    <button (click)="removeItem(i)">刪除</button>
  </div>
</div>
<button (click)="addItem()">新增商品</button>


3. 中階案例:報名表(多位參加者)

需求

  • 使用者可以替多位朋友同時報名活動。
  • 每位參加者需要:姓名、Email。
  • 驗證:姓名必填,Email 格式必須正確。
  • 支援新增/刪除參加者。

程式碼

form = this.fb.group({
  participants: this.fb.array([
    this.fb.group({
      name: ['王小明', Validators.required],
      email: ['xiaoming@example.com', [Validators.required, Validators.email]]
    }),
    this.fb.group({
      name: ['林小美', Validators.required],
      email: ['xiaomei@example.com', [Validators.required, Validators.email]]
    })
  ])
});

get participants(): FormArray {
  return this.form.get('participants') as FormArray;
}

addParticipant() {
  this.participants.push(this.fb.group({
    name: [''],
    email: ['']
  }));
}

removeParticipant(i: number) {
  this.participants.removeAt(i);
}


4. 壓軸案例:戲院座位表(FormArray → FormArray)

需求

  • 戲院:信義威秀
  • 場廳:第 1 廳
  • 場次:2025/09/01 19:30《沙丘 2》
  • 座位表:3 排 × 4 欄
    • A1 一般
    • A2 無障礙座位
    • A3 維修中
    • A4 無座位
    • 其他座位為一般

程式碼

type SeatStatus = '一般' | '無障礙座位' | '維修中' | '無座位';

form = this.fb.group({
  auditoriums: this.fb.array([
    this.fb.group({
      name: ['第1廳'],
      showtimes: this.fb.array([
        this.fb.group({
          movieTitle: ['沙丘 2'],
          startAt: ['2025-09-01T19:30:00+08:00'],
          seats: this.fb.array([
            // Row A
            this.fb.array([
              this.fb.group({ label: ['A1'], status: ['一般' as SeatStatus] }),
              this.fb.group({ label: ['A2'], status: ['無障礙座位' as SeatStatus] }),
              this.fb.group({ label: ['A3'], status: ['維修中' as SeatStatus] }),
              this.fb.group({ label: ['A4'], status: ['無座位' as SeatStatus] })
            ]),
            // Row B
            this.fb.array([
              this.fb.group({ label: ['B1'], status: ['一般' as SeatStatus] }),
              this.fb.group({ label: ['B2'], status: ['一般' as SeatStatus] }),
              this.fb.group({ label: ['B3'], status: ['一般' as SeatStatus] }),
              this.fb.group({ label: ['B4'], status: ['一般' as SeatStatus] })
            ]),
            // Row C
            this.fb.array([
              this.fb.group({ label: ['C1'], status: ['一般' as SeatStatus] }),
              this.fb.group({ label: ['C2'], status: ['無障礙座位' as SeatStatus] }),
              this.fb.group({ label: ['C3'], status: ['一般' as SeatStatus] }),
              this.fb.group({ label: ['C4'], status: ['一般' as SeatStatus] })
            ])
          ])
        })
      ])
    })
  ])
});

UI 綁定思路

  • 外層 auditoriums:場廳清單
  • showtimes:場次清單
  • seats:二維陣列(Row → Col)
  • 在模板用雙層 ngFor 繪出座位表,並用 <select> 或顏色標籤顯示「一般 / 無障礙座位 / 維修中 / 無座位」

網站示意:Day23


5. 動態操作與最佳實務

購物車 / 報名表

  • 新增或刪除商品 / 參加者。
  • 欄位驗證直接加在 FormGroup 上,錯誤集中顯示。

戲院座位表

  • 新增/刪除 Row 或 Col。
  • 批次修改:
    • 將整排標記為「無座位」
    • 將某些座位改為「維修中」

最佳實務

  • FormArray 不要直接操作 controls,盡量透過 getter 與 helper 方法。
  • 巢狀太深時,要考慮切分成子元件(例如:SeatRowComponent)。
  • 狀態值建議用 enum 或 string literal type,避免錯字。

小結

  • FormGroup 適合固定結構,FormArray 適合動態集合。
  • 從簡單的「購物車」、到中階的「報名表」、再到複雜的「戲院座位表」,可以看到 FormArray 的威力。
  • Reactive Forms 不僅能表現結構,還能映射真實世界中 一對多、多維集合 的資料模型。

👉 下一篇(篇四),我們將探討 跨元件巢狀與大表單拆分,讓大型表單不至於失控,維持良好維護性。


上一篇
Day 22:Angular Reactive Forms –群組驗證與跨欄位邏輯
系列文
Angular 進階實務 30天23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言